{T}

会议室预定系统:文件上传 OSS

前面实现了头像的上传:

图片是保存在服务器 uploads 目录下的:

然后把这个目录设置为静态文件目录:

这样图片就能直接访问了:

数据库里保存的也是这个路径:

这样是能完成功能,但有一些问题:

文件直接保存在某个目录下,服务器磁盘空间是有上限的,如果满了怎么办?

这些文件都没有一个管理界面来管理,很不方便。

所以,一般没人这么干,都会用 OSS 来做文件的管理,比如阿里云 OSS、或者用 minio 自己搭的 OSS 服务。

这节就把头像上传到用 minio 搭的 OSS。

把 minio 的 docker 镜像跑起来:

指定本地的某个目录,映射到容器里的 /bitnami/minio/data 目录。

指定端口 9000 和 9001 的映射。(9000 是文件访问的端口,9001 是管理页面的端口)

然后指定登录的用户名、密码 MINIO_ROOT_USER、MINIO_ROOT_PASSWORD

跑起来之后,访问下 http://localhost:9001

输入用户名密码后点击登录:

创建个 bucket:

设置下可以公开访问:

然后上传个文件:

复制路径,然后浏览器访问下:

http://localhost:9000/meeting-room-booking-system/headpic1.jpg

这样,文件就上传到了 OSS 里的 bucket,并且能够访问了。

这不比直接把文件放到文件目录下方便的多?

然后直接前端直传 minio 就好了:

上传完之后把 url 给服务端保存到数据库就行。

但是这个 accessKey 也不能暴露到前端代码里,需要在服务端做预签名。

这个在前端直传文件到 minio那节讲过。

进入 backend 的项目,安装 minio 的包:

code
npm install --save minio

然后创建个 minio 模块:

code
nest g module minio
javascript
import { Global, Module } from '@nestjs/common';
import * as Minio from 'minio';

@Global()
@Module({
    providers: [
        {
            provide: 'MINIO_CLIENT',
            async useFactory() {
                const client = new Minio.Client({
                        endPoint: 'localhost',
                        port: 9000,
                        useSSL: false,
                        accessKey: '',
                        secretKey: ''
                    })
                return client;
            }
          }
    ],
    exports: ['MINIO_CLIENT']
})
export class MinioModule {}

把 minio client 封装成 provider,放到 exports 里,并设置模块为 @Global。

用到 accessKey 和 secretKey 在这里创建:

这些当然也可以放到配置文件里配置:

code
# minio 配置
minio_endpoint=localhost
minio_port=9000
minio_access_key=xxx
minio_secret_key=xxx

因为 ConfigModule 声明为了全局模块:

所以可以直接在 MinioModule 里注入 ConfigService:

javascript
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as Minio from 'minio';

@Global()
@Module({
    providers: [
        {
            provide: 'MINIO_CLIENT',
            async useFactory(configService: ConfigService) {
                const client = new Minio.Client({
                    endPoint: configService.get('minio_endpoint'),
                    port: +configService.get('minio_port'),
                    useSSL: false,
                    accessKey: configService.get('minio_access_key'),
                    secretKey: configService.get('minio_secret_key')
                })
                return client;
            },
            inject: [ConfigService]
          },
    ],
    exports: ['MINIO_CLIENT']
})
export class MinioModule {}

然后创建 MinioController

code
nest g controller minio --no-spec

注入 Minio Client:

javascript
import { Controller, Get, Inject, Query } from '@nestjs/common';
import * as Minio from 'minio';

@Controller('minio')
export class MinioController {

    @Inject('MINIO_CLIENT')
    private minioClient: Minio.Client;

    @Get('presignedUrl')
    presignedPutObject(@Query('name') name: string) {
        return this.minioClient.presignedPutObject('meeting-room-booking-system', name, 3600);
    }
}

presignedPutObject 第一个参数是 buckectName,第二个参数是 objectName,第三个参数是 expires。

bucketName 就是 meeting-room-booking-system,这个也可以抽到 .env 文件里,用 configService 读取。

objectName 需要上传文件的时候拿到 file.name 作为参数传入。

expires 是生成的临时签名的过期时间,指定 3600 秒,也就是一小时。

调用下这个接口试试:

可以看到,返回了 aaa.png 的预签名的 url,这样前端不需要 accessKey 也可以用这个 url 来上传文件到 minio 了。

在 frontend_user 项目里用一下:

之前是指定了 action 参数,当选择文件后,antd 的 Dragger 组件会自动上传文件:

它还支持函数的形式,会传入 file 然后做处理后返回 url:

这里很显然就可以调用 presignedUrl 接口,拿到直传 minio 的 url。

在 interfaces.ts 添加一个接口:

javascript
export async function presignedUrl(fileName: string) {
    return axiosInstance.get(`/minio/presignedUrl?name=${fileName}`);
}

然后 action 这里调用下:

javascript
action: async (file) => {
    const res = await presignedUrl(file.name);
    return res.data.data;
},
async customRequest(options) {
    const { onSuccess, file, action } = options;

    const res = await axios.put(action, file);

    onSuccess!(res.data);
},

为什么要 customRequest 呢?

因为默认 Dragger 是用 FormData 的格式上传的,也就是 key value 的格式。

指定的 name 就是 key。

但是 minio 要求直接把文件放到 body 里。

所以要用 customRequest 自定义请求方式。

试一下:

提示上传成功。

在 minio 管理界面也可以看到这个文件:

接下来只要改下 onChange 的值,以及回显的图片的 url 就好了。

javascript
onChange('http://localhost:9000/meeting-room-booking-system/' + info.file.name);

试下效果:

输入验证码,点击修改,会提示更新成功:

去数据库里看下:

确实改过来了。

这样,基于 minio 搭的 OSS 服务的图片上传功能就完成了。

然后改下右上角按钮,改成显示用户头像:

首先,用户信息更新完后,同步修改下 localStorage 里的 user_info

javascript
const userInfo = localStorage.getItem('user_info');
if(userInfo) {
    const info = JSON.parse(userInfo);
    info.headPic = values.headPic;
    info.nickName = values.nickName;

    localStorage.setItem('user_info', JSON.stringify(info));
}

测试下:

可以看到,点击修改后,localStorage 里的数据也同步更新了。

然后右上角的按钮也可以从 localStorage 里取最新的 headPic:

javascript
import { UserOutlined } from "@ant-design/icons";
import { Link, Outlet } from "react-router-dom";
import './index.css';
import { useEffect, useState } from "react";

export function Index() {

    const [headPic, setHeadPic] = useState();

    useEffect(() => {
        const userInfo = localStorage.getItem('user_info');
        if(userInfo) {
            const info = JSON.parse(userInfo);
            setHeadPic(info.headPic);
        }
    }, []);

    return <div id="index-container">
        <div className="header">
            <h1>会议室预定系统</h1>
            <Link to={'/update_info'} >
                {
                    headPic ? <img src={headPic} width={40} height={40} className="icon"/> : <UserOutlined className="icon"/>
                }
            </Link>
        </div>
        <div className="body">
            <Outlet></Outlet>
        </div>
    </div>
}

useState 创建一个状态来保存 headPic。

在 useEffect 里读取 localStrage 里的值,调用 setHeadPic。

渲染的时候如果 headPic 有值就渲染 img,否则渲染默认的 icon。

这样,头像就正确显示了。

总结

这节把文件上传从基于 multer 实现,保存在项目目录下,换成了基于 minio 实现的 OSS 服务。

是用前端直传 OSS,然后把文件 url 发给应用服务器的方式。

但是又不想在前端代码暴露 accessKey,所以是用的预签名的方式,服务端用 presignedPutObject 返回一个预签名 url 给前端。前端用这个 url 来发送 put 请求,来把文件直传 minio。

antd 的 Dragger 组件默认用 form data 来发送请求,通过 customRequest 来重写了上传逻辑。

这样,文件就都保存在了 minio 服务里,可以更方便的管理。